﻿#region 참조 네임스페이스

using Skying.ComponentModel;
using Skying.IO;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Threading;

#endregion

namespace Skying.Reflection.Update
{
	/// <summary>
	/// 업데이트하려는 파일들을 실제로 적용하기 위한 기능을 제공한다.
	/// </summary>
	class FileApplier
	{
		#region 상수

		/// <summary>
		/// 압축을 해제하는 디렉토리명 상수를 나타낸다.
		/// </summary>
		private static string _EXTRACTED_DIRECTORY_NAME = "Extracted";

		/// <summary>
		/// 업데이트가 적용된 파일명의 접두사 상수를 나타낸다.
		/// </summary>
		private static string _UPDATE_APPLIED_FILENAME_PREFIX = "UPDATED___";

		#endregion

		#region 변수

		/// <summary>
		/// 적용할 업데이트 게이트웨이 목록 큐를 나타낸다.
		/// </summary>
		private Queue<UpdateSchemaGateway> _gatewaysQueue;

		/// <summary>
		/// 업데이트 게이트웨이의 아이디에 대한 업데이트 예정 파일 사전 목록을 나타낸다.
		/// </summary>
		private IDictionary<string, IList<Assembly>> _reservedAssemblies;

		#endregion

		#region 프로퍼티

		/// <summary>
		/// 적용이 시작됐을 때 실행된다.
		/// </summary>
		public Action<AsyncCompletedEventArgs> ApplyStarted
		{
			get;
			set;
		}

		/// <summary>
		/// 적용이 완료됐을 때 실행된다.
		/// </summary>
		public Action<AsyncCompletedEventArgs> ApplyCompleted
		{
			get;
			set;
		}

		/// <summary>
		/// 적용 중 오류가 발생한 경우 실행된다.
		/// </summary>
		public Action<AsyncResultEventArgs<Exception>> Error
		{
			get;
			set;
		}

		#endregion

		#region 생성자/소멸자

		public FileApplier(IList<UpdateSchemaGateway> gateways, IDictionary<string, IList<Assembly>> reservedAssemblies)
		{
			this._gatewaysQueue = new Queue<UpdateSchemaGateway>(gateways);

			this._reservedAssemblies = reservedAssemblies;
		}

		#endregion

		#region 업데이트 적용

		/// <summary>
		/// 업데이트를 적용한다.
		/// </summary>
		public void Apply()
		{
			if (this._gatewaysQueue.Count == 0) return;

			try
			{
				ThreadPool.QueueUserWorkItem((userState) =>
				{
					this.Apply(userState as UpdateSchemaGateway);
				}, this._gatewaysQueue.Dequeue());
			}
			catch (Exception error)
			{
				Synchronization.InvokeAndCallback(() => { return error; }, this.Error, null);
			}

			Synchronization.Synchronize(this.ApplyStarted);
		}

		/// <summary>
		/// 업데이트 게이트웨이에 대해서 업데이트를 적용한다.
		/// </summary>
		/// <param name="gateway">업데이트 정보가 담겨있는 업데이트 게이트웨이를 설정한다.</param>
		private void Apply(UpdateSchemaGateway gateway)
		{
			try
			{
				string downloadedUpdateFilePath = UpdateDownloader.GetDownloadPath(gateway);

				if (!File.Exists(downloadedUpdateFilePath)) return;

				// 압축을 해제할 전체 경로를 합성한다.
				string extractedPath = string.Format(@"{0}\{1}", Path.GetDirectoryName(downloadedUpdateFilePath), _EXTRACTED_DIRECTORY_NAME);

				if (!Directory.Exists(extractedPath))
				{
					Directory.CreateDirectory(extractedPath);
				}

				// 압축 파일을 해제 경로에 압축 해제한다.
				using (ZipArchive zipArchive = new ZipArchive(new FileStream(downloadedUpdateFilePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read, false))
				{
					zipArchive.ExtractToDirectory(extractedPath);
				}

				// 엔트리 어셈블리의 전체 경로를 가져온다.
				string entryAssemblyPath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);

				IList<Assembly> reservedAssembly = null;

				if (this._reservedAssemblies.ContainsKey(gateway.Id))
				{
					reservedAssembly = this._reservedAssemblies[gateway.Id];
				}

				// 엔트리 어셈블리의 최초 경로와 압축을 해제한 경로를 회귀적으로 비교하고 조건에 따라 설치되어 있는 파일의 이름을 변경한다.
				RenameAndUpdate(extractedPath, entryAssemblyPath, reservedAssembly);
			}
			catch (Exception error)
			{
				Synchronization.InvokeAndCallback(() => { return error; }, this.Error, null);
			}
			finally
			{
				if (this._gatewaysQueue.Count > 0)
				{
					ThreadPool.QueueUserWorkItem((userState) => { this.Apply(userState as UpdateSchemaGateway); }, this._gatewaysQueue.Dequeue());
				}
				else
				{
					Synchronization.Synchronize(this.ApplyCompleted);
				}
			}
		}

		/// <summary>
		/// 업데이트를 적용받는 경로와 업데이트 대상 경로를 서로 비교하여 경로와 이름이 동일한 경우 파일명을 변경한다.
		/// </summary>
		/// <param name="destinationsPath">업데이트를 적용받는 경로를 설정한다.</param>
		/// <param name="sourcesPath">업데이트 대상 경로를 설정한다.</param>
		private static void RenameAndUpdate(string sourcesPath, string destinationsPath, IList<Assembly> reservedAssembly)
		{
			// 업데이트 적용 경로와 업데이트 대상 경로의 파일들의 파일 정보 객체를 가져온다.
			FileInfo[] sourceFileInfos = FileHelper.GetFileInfos(Directory.GetFiles(sourcesPath));
			FileInfo[] destinationFileInfos = FileHelper.GetFileInfos(Directory.GetFiles(destinationsPath));

			// 업데이트 대상 파일을 조회하여 업데이트 적용 파일과 동일한 이름을 갖는 파일은 이름을 변경한다.
			foreach (FileInfo sourceFileInfo in sourceFileInfos)
			{
				FileInfo matchedFileInfo = destinationFileInfos.SingleOrDefault(t => t.Name.Equals(sourceFileInfo.Name));

				if (matchedFileInfo != null && matchedFileInfo.Exists)
				{
					if (reservedAssembly != null)
					{
						Assembly matchedAssembly = reservedAssembly.SingleOrDefault<Assembly>(t => t.ManifestModule.Name.Equals(matchedFileInfo.Name));

						if (matchedAssembly != null)
						{
							FileVersionInfo sourceFileVersionInfo = FileVersionInfo.GetVersionInfo(sourceFileInfo.FullName);
							FileVersionInfo matchedFileVersionInfo = FileVersionInfo.GetVersionInfo(matchedFileInfo.FullName);

							if (matchedFileVersionInfo.FileVersion.CompareTo(sourceFileVersionInfo.FileVersion) >= 0 &&
								matchedFileInfo.Length.Equals(sourceFileInfo.Length))
							{
								continue;
							}
						}
					}

					matchedFileInfo.Attributes |= FileAttributes.Hidden;

					File.Move(matchedFileInfo.FullName, string.Format(@"{0}\{1}{2}", matchedFileInfo.DirectoryName, _UPDATE_APPLIED_FILENAME_PREFIX, matchedFileInfo.Name));
				}

				sourceFileInfo.CopyTo(string.Format(@"{0}\{1}", destinationsPath, sourceFileInfo.Name));
			}

			// 업데이트 적용 경로와 업데이트 대상 경로의 하위 디렉토리 정보 객체를 가져온다.
			DirectoryInfo[] sourceDirectoryInfos = DirectoryHelper.GetDirectoryInfos(Directory.GetDirectories(sourcesPath));
			DirectoryInfo[] destinationDirectoryInfos = DirectoryHelper.GetDirectoryInfos(Directory.GetDirectories(destinationsPath));

			// 업데이트 대상 경로의 하위 디렉토리를 조회하여 업데이트 적용 경로의 하위 디렉토리와 일치하는 것이 있으면 메서드를 회귀적으로 호출한다.
			foreach (DirectoryInfo sourceDirectoryInfo in sourceDirectoryInfos)
			{
				string subDirectoryPath = string.Format(@"{0}\{1}", destinationsPath, sourceDirectoryInfo.Name);

				if (!Directory.Exists(subDirectoryPath))
				{
					Directory.CreateDirectory(subDirectoryPath);
				}

				RenameAndUpdate(sourceDirectoryInfo.FullName, subDirectoryPath, reservedAssembly);
			}
		}

		#endregion

		#region 정리

		/// <summary>
		/// 업데이트를 위해 이름이 변경된 파일들을 모두 삭제한다.
		/// </summary>
		public static void DeleteRenamedFiles()
		{
			DeleteRenamedFiles(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location));
		}

		/// <summary>
		/// <para>특정 경로 상에서 업데이트를 위해 이름이 변경된 파일들을 모두 삭제한다.</para>
		/// <para>경로 내의 하위 경로들에 대해서도 회귀적으로 처리한다.</para>
		/// </summary>
		private static void DeleteRenamedFiles(string path)
		{
			// 경로 내에서 업데이트하기 위해 이름이 변경된 파일 목록을 가져온다.
			IEnumerable<FileInfo> fileInfos = FileHelper.GetFileInfos(path).Where(t => t.Name.StartsWith(_UPDATE_APPLIED_FILENAME_PREFIX));

			// 이름이 변경된 파일들을 모두 삭제한다.
			foreach (FileInfo fileInfo in fileInfos)
			{
				fileInfo.Delete();
			}

			// 경로 내의 디렉토리 정보 목록을 가져온다.
			DirectoryInfo[] directoryInfos = DirectoryHelper.GetDirectoryInfos(path);

			// 경로 내의 디렉토리에 대해서 변경된 파일들을 회귀적으로 모두 삭제한다.
			foreach (DirectoryInfo directoryInfo in directoryInfos)
			{
				DeleteRenamedFiles(directoryInfo.FullName);
			}
		}

		#endregion
	}
}